Skip to content

Scope HTTP client redirect following to the request's origin#3075

Open
maxisbey wants to merge 1 commit into
mainfrom
scope-headers-to-origin
Open

Scope HTTP client redirect following to the request's origin#3075
maxisbey wants to merge 1 commit into
mainfrom
scope-headers-to-origin

Conversation

@maxisbey

@maxisbey maxisbey commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Clients created by the SDK's httpx factory always enabled follow_redirects=True, so everything configured on a client — headers, auth, request bodies — was re-sent to whatever host a Location header named, and a misconfigured URL failed with an unhelpful Unexpected content type: error. This PR scopes redirect following to the request's origin and makes every refused redirect fail loudly with the target URL and the remedy.

Motivation and Context

follow_redirects=True was added for operational convenience: trailing-slash 307s from Starlette-mounted servers (#105, #732) and hosted-server redirects (#283). The dominant case — the same-origin slash fix — keeps working exactly as before; the server-side cause was also fixed long ago in #1115. What the blanket default additionally did was silently re-send client configuration to arbitrary redirect targets and silently mask misconfigured URLs. httpx's own default is follow_redirects=False for the same reason (encode/httpx#2533 keeps its cross-origin strip list deliberately minimal and leaves per-application policy to the application).

Clients built by create_mcp_http_client now:

  • follow redirects within the same origin (scheme, host, port) and same-host http→https upgrades on default ports — trailing-slash normalization and TLS-upgrade redirects keep working, verified end-to-end against real Starlette apps;
  • raise the new RedirectError for any other redirect target, before the next request is sent, with a message naming the resolved target and how to proceed (connect to the final URL, or supply your own pre-configured httpx.AsyncClient after verifying the destination).

Transports resolve a refused redirect in-band — a request gets a JSON-RPC error rather than a hung caller, notifications are delivered to the session's message handler, and the standalone GET stream stops retrying an endpoint that redirects on every attempt. Caller-supplied clients that follow no redirects (httpx's default) get the same clear error on POST, GET-stream, and SSE-connect paths. OAuth discovery, registration, token, refresh, and the identity-assertion token exchange fail loudly on redirect responses instead of silently trying the next URL or abandoning the discovery chain.

create_mcp_http_client and RedirectError are now exported from the top-level mcp package (a deliberate API addition): the docs previously told users to build their own httpx.AsyncClient for headers/auth, which quietly lost the SDK's defaults. Docs and examples now configure clients through the factory; the general-purpose simple-tool fetch example gets a browser-like client of its own, since it fetches arbitrary web pages rather than a configured MCP endpoint.

How Has This Been Tested?

  • Real-server verification: booted MCPServer.streamable_http_app() under uvicorn and drove it with the real client. A trailing-slash /mcp/ URL initializes and completes a full session lifecycle (every request rides a genuine Starlette 307); a redirect to a different origin resolves initialization with a clear error naming the target; a typo'd path still yields the usual not-found error.
  • New unit and transport tests (all proven failing on the base revision): same-origin and https-upgrade redirects followed with method/body/headers intact; other-host, other-port, https→http, and empty-host absolute-form Locations refused before a second request is sent; unparsable Location defers to httpx's own error; per-request and client-level headers behave identically; request/notification/resumption/GET-stream/SSE paths each surface the in-band error; OAuth handlers raise on redirect responses; a caller-supplied follow_redirects=True client is untouched (opt-in preserved).
  • A parametrized test asserts the target-resolution helper matches response.next_request.url from httpx itself across relative, protocol-relative, absolute-form-without-host, and cross-origin shapes, so an httpx behavior change surfaces as a test failure rather than a silent policy divergence.
  • Full suite: 5458 passed, 100% coverage including strict-no-cover, pyright and ruff clean.

Breaking Changes

Yes, deliberate and documented in docs/migration.md: a redirect to a different origin now raises mcp.RedirectError instead of being followed. Same-origin redirects and same-host https upgrades are unaffected. Deployments that genuinely need cross-origin redirect following can pass their own httpx.AsyncClient(follow_redirects=True) to streamable_http_client / sse_client.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

  • Four # pragma: no branch / # pragma: no cover comments are added in tests: three for the coverage.py nested-async with phantom-arc misreport documented in AGENTS.md, one marking an unreachable context-manager body in a connect-failure test.
  • Known, accepted scope edges: resumption/reconnection GETs with a caller-supplied non-following client surface httpx's generic status error rather than the typed one (the factory's clients get the typed error everywhere); the origin comparison is deliberately conservative and fails loud on exotic-but-equivalent forms (IPv6 long-form vs. shorthand, trailing-dot hosts, non-default-port https upgrades) — supply your own client for those chains.
  • The RedirectPolicy proposal in fix: add SSRF redirect protection to httpx client factory #2180 addressed the scheme-downgrade slice of the same default; with redirect targets now vetted at the factory for every consumer, that policy knob is superseded by this change.

AI Disclaimer

create_mcp_http_client followed every redirect, so everything configured
on a client (headers, auth, request bodies) was re-sent to whatever host
a Location header named. Clients built by the factory now follow
redirects within the same origin (scheme, host, and port), plus
http-to-https upgrades of the same host on default ports, and raise the
new RedirectError for anything else - before the next request is sent.

- transports resolve a refused redirect in-band: requests get a JSON-RPC
  error naming the target and the remedy, notifications are delivered to
  the session's message handler; the standalone GET stream stops
  retrying an endpoint that keeps redirecting
- caller-supplied clients that follow no redirects get the same clear
  error on POST, GET stream, and SSE connect instead of an opaque
  content-type error
- OAuth discovery, registration, token, refresh, and the
  identity-assertion token exchange fail loudly on redirect responses
  instead of silently trying the next URL or abandoning the discovery
  chain
- RedirectError and create_mcp_http_client are exported from the
  top-level mcp package; migration.md documents the behavior change;
  docs and examples configure clients through the factory, and the
  general-purpose fetch example uses a browser-like client of its own
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3075.mcp-python-docs.pages.dev
Deployment https://2e0d3ec4.mcp-python-docs.pages.dev
Commit b8a107d
Triggered by @maxisbey
Updated 2026-07-08 10:24:13 UTC

@maxisbey maxisbey requested a review from pcarleton July 8, 2026 10:28
@maxisbey maxisbey marked this pull request as ready for review July 8, 2026 16:39
Comment on lines 417 to +421

async def _handle_token_response(self, response: httpx.Response) -> None:
"""Handle token exchange response."""
if response.has_redirect_location:
raise redirect_error(response, context="OAuth token request")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 OAuthClientProvider._handle_protected_resource_response (oauth2.py:281) is a near-duplicate of utils.handle_protected_resource_response but didn't get the new has_redirect_location check, so a 307 through it still raises the old generic OAuthFlowError instead of RedirectError with guidance. The method is dead code in src/ (only tests call it), so nothing fails at runtime — consider deleting it (repointing its tests at the utils function) or adding the same check so the two copies don't diverge.

Extended reasoning...

What the bug is. This PR standardizes redirect handling across the OAuth response handlers: _handle_token_response (oauth2.py:420), _handle_refresh_response (oauth2.py:474), the identity-assertion token exchange, and the module-level mcp.client.auth.utils.handle_protected_resource_response / handle_auth_metadata_response / handle_registration_response all gained an early if response.has_redirect_location: raise redirect_error(...) check. One handler was left out: OAuthClientProvider._handle_protected_resource_response at src/mcp/client/auth/oauth2.py:281, which is a near-duplicate of the utils function.

Why nothing fails at runtime. Grep confirms the provider method has zero callers in src/ — the live discovery path in async_auth_flow calls the module-level utils.handle_protected_resource_response (which the PR did update). The only callers are tests/client/test_auth.py:543/563/583. So this is dead library code kept alive by its tests, and the divergence has no production failure scenario today.

What the divergence looks like. Concrete walkthrough of a 307 response through each copy:

  1. Server answers a PRM discovery GET with 307 Location: https://other.example.com/.well-known/oauth-protected-resource.
  2. Through the live path, utils.handle_protected_resource_response hits the new response.has_redirect_location check first and raises RedirectError("OAuth protected resource metadata request: server at ... responded with 307 redirecting to ...") — the loud, actionable error this PR set out to produce.
  3. Through the dead provider method, there is no such check: status_code == 200 is false, status_code == 404 is false, so the else branch raises the old generic OAuthFlowError("Protected Resource Metadata request failed: 307") — exactly the unhelpful failure mode the PR eliminates everywhere else. (Note that branch carries a # pragma: no cover, so even the tests don't exercise it.)

Why it matters. Two copies of the same logic now embody two different redirect policies. Any future caller (or a refactor that swaps the utils call back to the method — it was clearly the original implementation the utils function was extracted from) silently reverts to the old behavior. Since every sibling handler in the same class got the check, this copy was plainly missed rather than deliberately excluded.

How to fix. Preferably delete the dead method and repoint its three tests at utils.handle_protected_resource_response (which also removes the # pragma: no cover branches it carries). Alternatively, give it the same two-line has_redirect_location check as its siblings so the copies stay aligned.

Not a merge blocker: the method is unreachable in the production flow, so merging as-is breaks nothing — this is a code-hygiene/consistency cleanup.

Comment thread docs/client/transports.md
```

That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx.AsyncClient` configured the way MCP needs: `follow_redirects=True`, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.
That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx.AsyncClient` configured the way MCP needs: same-origin redirect handling, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The 'Bring your own httpx.AsyncClient' section's prose now contradicts the tutorial003 snippet it embeds: this PR switched the snippet to create_mcp_http_client, but line 46 still says to "build the httpx.AsyncClient yourself" and the info box still claims "The SDK adds nothing on top and takes nothing away" — no longer true, since the factory adds the same-origin redirect policy (raising mcp.RedirectError) and MCP default timeouts. Update the prose (and the httpx.AsyncClient(auth=OAuthClientProvider(...)) example) to match the snippet and the new migration.md guidance that prefers the factory.

Extended reasoning...

What the bug is. docs/client/transports.md has a section titled "Bring your own httpx.AsyncClient" that embeds docs_src/client_transports/tutorial003.py. This PR rewrote that snippet from a plain httpx.AsyncClient(...) with follow_redirects=True to create_mcp_http_client(...), and updated the Streamable HTTP intro paragraph (line 32) accordingly — but the rest of the section's prose was left untouched, so it now contradicts the very code it displays.

The specific contradictions. Line 46 tells the reader to "build the httpx.AsyncClient yourself and hand it to streamable_http_client", and the info box near line 70 says: "The SDK adds nothing on top and takes nothing away. It is also where OAuth plugs in: httpx.AsyncClient(auth=OAuthClientProvider(...))." Both statements described the old snippet accurately. They do not describe the new one: create_mcp_http_client demonstrably adds SDK behavior on top of httpx — it installs the _raise_on_disallowed_redirect response event hook (which raises the new mcp.RedirectError on cross-origin redirects) and applies MCP default timeouts (30s connect/write/pool, 300s read). "Adds nothing on top" is now a specific, false claim sitting directly around code that proves otherwise.

Why this matters beyond cosmetics. The stale prose actively steers readers toward constructing a plain httpx.AsyncClient — which is exactly the pitfall this PR's own docs/migration.md addition warns about: "a plain httpx.AsyncClient does not follow redirects at all, and configuring follow_redirects=True yourself sends everything on the client (headers, auth, bodies) to whichever server a redirect names." So the SDK's documentation now gives conflicting guidance on the same question, with transports.md recommending the pattern migration.md says to avoid.

Step-by-step proof. (1) A reader lands on the "Bring your own" section needing an Authorization header. (2) Following line 46 and the info box literally, they write httpx.AsyncClient(headers={"Authorization": "Bearer ..."}) — a plain client, no follow_redirects. (3) Their server is Starlette-mounted at /notes/ and they connect to /notes (the trailing-slash case docs/run/asgi.md now explicitly calls out as answering with a redirect). (4) The plain client follows no redirects; the transport's new response.has_redirect_location check fires and the request resolves with a RedirectError-derived JSON-RPC error telling them to connect to the final URL — a loud, recoverable failure, but one the page's own snippet (which uses the factory and follows the same-origin redirect transparently) would never have produced. (5) Meanwhile the reader is left believing "the SDK adds nothing on top" while the embedded example's client raises mcp.RedirectError on any cross-origin redirect — behavior they were told doesn't exist.

Why nothing prevents it. The docs snippets are executed as code (so tutorial003 itself is correct and tested), but prose is not checked against the snippets it surrounds, so the inconsistency ships silently.

How to fix. Rewrite the section prose to match the snippet: say to build the client with create_mcp_http_client (which returns an httpx.AsyncClient with the SDK defaults — timeouts and same-origin redirect handling) and hand it to streamable_http_client. In the info box, drop "The SDK adds nothing on top and takes nothing away" and update the OAuth example to create_mcp_http_client(auth=OAuthClientProvider(...)), matching what docs_src/oauth_clients/tutorial001.py and migration.md now show. Nothing breaks at runtime — this is a docs-consistency fix, hence nit severity.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 24 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/mcp/client/auth/oauth2.py">

<violation number="1" location="src/mcp/client/auth/oauth2.py:420">
P3: `OAuthClientProvider._handle_protected_resource_response` (around line 281) is a near-duplicate of `utils.handle_protected_resource_response` but wasn't updated with the same `has_redirect_location` check that all sibling handlers here received. It's currently dead code in `src/` (the live discovery path calls the utils version, which was updated), but the two copies now embody different redirect policies. Consider either deleting the dead method (repointing its tests at the utils function) or adding the same check so the copies stay aligned.</violation>
</file>

<file name="src/mcp/client/streamable_http.py">

<violation number="1" location="src/mcp/client/streamable_http.py:566">
P2: Reconnection redirects can still be treated as transient failures and consume retry budget instead of returning the new redirect guidance. `_handle_reconnection` now catches `RedirectError`, but it does not convert 3xx responses before `raise_for_status()`, so default no-follow redirect responses are handled by the generic retry path.</violation>
</file>

<file name="docs/migration.md">

<violation number="1" location="docs/migration.md:1624">
P3: The migration note overstates which HTTPS-upgrade redirects remain allowed. The redirect policy described for this change only preserves same-host HTTP→HTTPS upgrades on default ports, so adding that qualifier here would avoid misleading deployments that use custom ports into expecting those redirects to be followed.</violation>
</file>

<file name="docs/client/transports.md">

<violation number="1" location="docs/client/transports.md:32">
P2: The prose in this section now contradicts the embedded `tutorial003.py` snippet. The snippet was updated from a plain `httpx.AsyncClient` to `create_mcp_http_client(...)`, but downstream text (around line 46) still tells the reader to "build the `httpx.AsyncClient` yourself" and the info box claims "The SDK adds nothing on top and takes nothing away." Both are now false — `create_mcp_http_client` installs the `_raise_on_disallowed_redirect` response hook and applies MCP default timeouts. This creates conflicting guidance with `docs/migration.md`, which explicitly warns against using a plain `httpx.AsyncClient`. The surrounding prose (and the `httpx.AsyncClient(auth=OAuthClientProvider(...))` example in the info box) should be updated to match the factory-based approach the snippet now demonstrates.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

# Stream ended again without response - reconnect again (reset attempt counter)
logger.info("SSE stream disconnected, reconnecting...")
await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0)
except RedirectError:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Reconnection redirects can still be treated as transient failures and consume retry budget instead of returning the new redirect guidance. _handle_reconnection now catches RedirectError, but it does not convert 3xx responses before raise_for_status(), so default no-follow redirect responses are handled by the generic retry path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/streamable_http.py, line 566:

<comment>Reconnection redirects can still be treated as transient failures and consume retry budget instead of returning the new redirect guidance. `_handle_reconnection` now catches `RedirectError`, but it does not convert 3xx responses before `raise_for_status()`, so default no-follow redirect responses are handled by the generic retry path.</comment>

<file context>
@@ -528,6 +563,11 @@ async def _handle_reconnection(
                 # Stream ended again without response - reconnect again (reset attempt counter)
                 logger.info("SSE stream disconnected, reconnecting...")
                 await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0)
+        except RedirectError:
+            # A redirected stream endpoint will redirect on every retry;
+            # propagate so the request resolves with the guidance instead
</file context>

Comment thread docs/client/transports.md
@@ -29,7 +29,7 @@ Pass a URL string and you get **Streamable HTTP**, the transport you deploy behi
--8<-- "docs_src/client_transports/tutorial002.py"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The prose in this section now contradicts the embedded tutorial003.py snippet. The snippet was updated from a plain httpx.AsyncClient to create_mcp_http_client(...), but downstream text (around line 46) still tells the reader to "build the httpx.AsyncClient yourself" and the info box claims "The SDK adds nothing on top and takes nothing away." Both are now false — create_mcp_http_client installs the _raise_on_disallowed_redirect response hook and applies MCP default timeouts. This creates conflicting guidance with docs/migration.md, which explicitly warns against using a plain httpx.AsyncClient. The surrounding prose (and the httpx.AsyncClient(auth=OAuthClientProvider(...)) example in the info box) should be updated to match the factory-based approach the snippet now demonstrates.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/client/transports.md, line 32:

<comment>The prose in this section now contradicts the embedded `tutorial003.py` snippet. The snippet was updated from a plain `httpx.AsyncClient` to `create_mcp_http_client(...)`, but downstream text (around line 46) still tells the reader to "build the `httpx.AsyncClient` yourself" and the info box claims "The SDK adds nothing on top and takes nothing away." Both are now false — `create_mcp_http_client` installs the `_raise_on_disallowed_redirect` response hook and applies MCP default timeouts. This creates conflicting guidance with `docs/migration.md`, which explicitly warns against using a plain `httpx.AsyncClient`. The surrounding prose (and the `httpx.AsyncClient(auth=OAuthClientProvider(...))` example in the info box) should be updated to match the factory-based approach the snippet now demonstrates.</comment>

<file context>
@@ -29,7 +29,7 @@ Pass a URL string and you get **Streamable HTTP**, the transport you deploy behi

-That is the whole production client. Client wraps the URL in streamable_http_client(...) for you, on top of an httpx.AsyncClient configured the way MCP needs: follow_redirects=True, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.
+That is the whole production client. Client wraps the URL in streamable_http_client(...) for you, on top of an httpx.AsyncClient configured the way MCP needs: same-origin redirect handling, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.

!!! check
</file context>


</details>


async def _handle_token_response(self, response: httpx.Response) -> None:
"""Handle token exchange response."""
if response.has_redirect_location:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: OAuthClientProvider._handle_protected_resource_response (around line 281) is a near-duplicate of utils.handle_protected_resource_response but wasn't updated with the same has_redirect_location check that all sibling handlers here received. It's currently dead code in src/ (the live discovery path calls the utils version, which was updated), but the two copies now embody different redirect policies. Consider either deleting the dead method (repointing its tests at the utils function) or adding the same check so the copies stay aligned.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/auth/oauth2.py, line 420:

<comment>`OAuthClientProvider._handle_protected_resource_response` (around line 281) is a near-duplicate of `utils.handle_protected_resource_response` but wasn't updated with the same `has_redirect_location` check that all sibling handlers here received. It's currently dead code in `src/` (the live discovery path calls the utils version, which was updated), but the two copies now embody different redirect policies. Consider either deleting the dead method (repointing its tests at the utils function) or adding the same check so the copies stay aligned.</comment>

<file context>
@@ -416,6 +417,8 @@ async def _exchange_token_authorization_code(
 
     async def _handle_token_response(self, response: httpx.Response) -> None:
         """Handle token exchange response."""
+        if response.has_redirect_location:
+            raise redirect_error(response, context="OAuth token request")
         if response.status_code not in {200, 201}:
</file context>

Comment thread docs/migration.md
Clients created by the SDK (including `Client("http://...")`, `sse_client`, and the
default client inside `streamable_http_client`) now vet redirect targets before
following. Redirects that stay on the same origin (scheme, host, and port) and
http-to-https upgrades of the same host still work; any other redirect raises

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The migration note overstates which HTTPS-upgrade redirects remain allowed. The redirect policy described for this change only preserves same-host HTTP→HTTPS upgrades on default ports, so adding that qualifier here would avoid misleading deployments that use custom ports into expecting those redirects to be followed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/migration.md, line 1624:

<comment>The migration note overstates which HTTPS-upgrade redirects remain allowed. The redirect policy described for this change only preserves same-host HTTP→HTTPS upgrades on default ports, so adding that qualifier here would avoid misleading deployments that use custom ports into expecting those redirects to be followed.</comment>

<file context>
@@ -1613,7 +1614,26 @@ async with http_client:
+Clients created by the SDK (including `Client("http://...")`, `sse_client`, and the
+default client inside `streamable_http_client`) now vet redirect targets before
+following. Redirects that stay on the same origin (scheme, host, and port) and
+http-to-https upgrades of the same host still work; any other redirect raises
+`mcp.RedirectError` naming the target instead of being followed. Everything
+configured on a client - headers, auth, request bodies - is sent to whichever
</file context>
Suggested change
http-to-https upgrades of the same host still work; any other redirect raises
http-to-https upgrades of the same host on default ports still work; any other redirect raises

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant